系列:從現場踩坑到 AI 工具 — IT Diagnostic Agent 開發實錄
昨天整篇在講「不要假設 API 會相似」。
今天要講一個相反的情況:有一類 API 真的可以複製貼上,而且一個 adapter 就能支援四五家。
前提是你知道它為什麼可以。
/v1/chat/completions 這個端點路徑,加上 {model, messages, max_tokens} 這個 body 形狀,事實上已經成為 LLM API 的通用格式。
不是因為有標準組織訂了規範,而是因為 OpenAI 先做了、生態圈(SDK、框架、工具)都繞著它長出來,後進者發現**「相容 OpenAI 格式」的邊際成本遠低於「教育市場接受自己的格式」**。
所以現在的情況是:Kimi(Moonshot)、Groq、DeepSeek、Together、還有很多——都提供 OpenAI 相容端點。
這對 Adapter Pattern 來說是一個巨大的紅利。
實際的實作:
async function callOpenAI(messages, systemPrompt){
const p = providers.openai;
if(!p.key) throw new Error('OpenAI API Key not set');
const res = await fetch(p.endpoint + '/chat/completions', {
method:'POST',
headers:{'Content-Type':'application/json','Authorization':'Bearer '+p.key},
body:JSON.stringify({
model:p.model,
max_tokens:1024,
messages:[{role:'system',content:systemPrompt}, ...messages]
})
});
const data = await res.json();
if(data.error) throw new Error(data.error.message);
return data.choices[0].message.content;
}
關鍵在第一行的 p.endpoint:
openai: {
key: localStorage.getItem('it_key_openai') || '',
model: localStorage.getItem('it_model_openai') || 'gpt-4o-mini',
endpoint: localStorage.getItem('it_ep_openai') || 'https://api.openai.com/v1'
}
端點是使用者可以改的。
這一個設計決定,讓這個 adapter 從「支援 OpenAI」變成「支援所有 OpenAI 相容服務」:
| 服務 | 端點填什麼 |
|---|---|
| OpenAI | https://api.openai.com/v1 |
| Kimi | Moonshot 的相容端點 |
| Groq | Groq 的相容端點 |
| DeepSeek | DeepSeek 的相容端點 |
| 任何自架的相容服務 | 你自己的 URL |
一個函式,一個可編輯的欄位,覆蓋了整個 OpenAI 相容生態。
比對一下 Gemini:Gemini 需要一整個獨立的 adapter,因為它的八個維度都不一樣。而這一整個生態圈只需要一個。
順帶記錄一下三家對 systemPrompt 的處理,因為這個對比很有意思:
Claude — 頂層獨立欄位
{model, max_tokens, system: systemPrompt, messages}
Gemini — 獨立欄位但要包一層
{system_instruction:{parts:[{text:systemPrompt}]}, contents, generationConfig}
OpenAI — 塞進對話陣列的第一則
messages:[{role:'system',content:systemPrompt}, ...messages]
第三種在概念上其實比較弱:它把「規則」和「對話」放在同一個容器裡。
系統提示是整場對話的憲法,把它變成對話的第 0 則訊息,觀念上是混在一起了。實務上也有影響——在很長的對話裡,第 0 則訊息離當前上下文越來越遠。
Claude 那種頂層 system 欄位的設計,把規則和內容分開,我認為是更好的抽象。
但 OpenAI 那種做法贏了市場。 這是技術史上很常見的結果。
當我要加本地模型支援時,發現 Ollama 也提供 OpenAI 相容端點。
於是 callOllama 長這樣:
async function callOllama(messages, systemPrompt){
const p = providers.ollama;
const res = await fetch(p.endpoint + '/v1/chat/completions', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
model:p.model,
messages:[{role:'system',content:systemPrompt}, ...messages]
})
});
const data = await res.json();
if(data.error) throw new Error(data.error.message || JSON.stringify(data.error));
return data.choices[0].message.content;
}
跟 callOpenAI 幾乎一模一樣。差別只有三處:
Authorization header — 本機跑的模型不需要 Keymax_tokens — 本地模型的輸出長度由模型和 Ollama 設定決定第三點是這篇的重點。
看這一行:
if(data.error) throw new Error(data.error.message || JSON.stringify(data.error));
再比對 OpenAI 版:
if(data.error) throw new Error(data.error.message);
多了 || JSON.stringify(data.error)。
這行 fallback 不是我為了寫得漂亮加的。它是被迫加的——因為 Ollama 回傳的錯誤,有時候 data.error 不是一個帶 .message 的物件。
如果沒有那段 fallback,錯誤訊息會顯示 undefined。使用者看到的是「發生錯誤:undefined」,等於什麼都沒說。
這五個字的 fallback,是「相容不等於一致」最具體的證據。
Ollama 相容了 OpenAI 的成功路徑——請求格式、回覆結構,都一樣。但錯誤路徑沒有完全相容。
而錯誤路徑恰恰是使用者最需要清楚訊息的時候。
這個現象我後來想了一下,覺得有它的必然性。
當一家服務說「我們相容 OpenAI 格式」時,他們測的是什麼?
送一個正常請求,收到一個正常回覆。 這就是相容性測試的 80%。
錯誤情境有多少種?Key 錯誤、模型不存在、參數超限、服務忙碌、內容被過濾、網路中斷、模型還在載入……每一種的錯誤形狀可能都不一樣,而且很多是各服務特有的(Ollama 有「模型還沒 pull」這種 OpenAI 根本不存在的錯誤)。
所以「相容」在實務上幾乎總是指「快樂路徑相容」。
這對接 API 的人有一個直接的教訓:
複製貼上一個相容的 adapter 時,成功路徑可以信任,錯誤處理必須自己重新想一遍。
相同 vs 不同
| 層面 | 相容程度 |
|---|---|
| 端點路徑 | 高 |
| 請求 body 形狀 | 高 |
| 成功回覆結構 | 高 |
| 認證方式 | 中(Ollama 完全不用) |
| 錯誤回覆結構 | 低 |
| 特有錯誤類型 | 幾乎不相容 |
可控 vs 不可控
我控制不了各家怎麼回錯誤。我控制得了自己的錯誤處理要多防禦。
那行 || JSON.stringify(data.error) 就是把不可控的部分,用可控的方式接住。寧可顯示一坨難看的 JSON,也不要顯示 undefined。
因為難看的 JSON 使用者還能貼給我看,undefined 什麼資訊都沒有。
因為 Ollama 相容 OpenAI 格式,加本地模型支援的程式碼工作量非常小。
但這件事有一個誤導性:真正花時間的不是 adapter,是使用者要怎麼正確設定 Ollama。
CORS、防火牆、OLLAMA_HOST、HTTPS 混合內容限制——這些都不是程式碼問題,是環境問題。而環境問題不能靠寫程式解決,只能靠設計引導。
這就是為什麼我後來做了一整個確認 modal。 那是 Day 20 的主題。
昨天的教訓是「不要假設 API 相似」,今天的教訓看起來相反,其實是同一件事的另一面:
要知道相似性從哪裡來。
Gemini 跟 Claude 不相似,因為它們是獨立設計的。
Kimi 跟 OpenAI 相似,因為 Kimi 刻意去相容 OpenAI。
前者的相似是巧合(所以不可靠),後者的相似是承諾(所以可以依賴)。
而承諾有它的邊界。 那個邊界,通常就在錯誤處理上。
明天預告: 本地模型的技術路徑通了。但我一開始以為這只是一個「順便加上去的功能」。後來實際接觸企業需求,才發現這可能是整個工具最重要的一個功能。
作者:Rich Chang | IT 基礎建設工程師 | 越南・柬埔寨・台灣